音频播放组件:进度条拖拽&滑动交互设计
概述
本节将音频进度条抽取为独立的 ProgressBar 组件,实现拖拽和滑动的交互设计,并处理移动端触摸事件和 UnoCSS 变体组(Variant Group)的样式写法。
进度条组件拆分
将进度条逻辑从 AudioPlayer 中提取为独立组件 ProgressBar.vue,实现关注点分离。
ProgressBar 组件实现
<template>
<div
ref="progressRef"
class="progress-bar"
@mousedown="handleMouseDown"
@touchstart="handleTouchStart"
>
<!-- 轨道 -->
<div class="progress-track">
<!-- 已播放部分 -->
<div class="progress-filled" :style="{ width: percent + '%' }" />
<!-- 滑块 -->
<div
class="progress-thumb"
:style="{ left: percent + '%' }"
/>
</div>
<!-- 时间显示 -->
<div class="progress-time">
<span>{{ formatTime(modelValue) }}</span>
<span>{{ formatTime(duration) }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
interface Props {
modelValue: number // 0-1 的百分比值
duration: number // 总时长(秒)
currentTime: number // 当前播放时间(秒)
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
const progressRef = ref<HTMLDivElement>()
const progressWidth = ref(0)
const isDragging = ref(false)
const percent = computed(() => props.modelValue * 100)
// 格式化时间为 mm:ss
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
// 计算鼠标/触摸位置对应的百分比
function getPercent(clientX: number): number {
if (!progressRef.value) return 0
const rect = progressRef.value.getBoundingClientRect()
const x = clientX - rect.left
const ratio = Math.max(0, Math.min(1, x / rect.width))
return ratio
}
// 鼠标事件
function handleMouseDown(e: MouseEvent) {
isDragging.value = true
const value = getPercent(e.clientX)
emit('update:modelValue', value)
const handleMouseMove = (e: MouseEvent) => {
if (isDragging.value) {
emit('update:modelValue', getPercent(e.clientX))
}
}
const handleMouseUp = () => {
isDragging.value = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
// 触摸事件(移动端)
function handleTouchStart(e: TouchEvent) {
isDragging.value = true
const touch = e.touches[0]
emit('update:modelValue', getPercent(touch.clientX))
const handleTouchMove = (e: TouchEvent) => {
if (isDragging.value) {
const touch = e.touches[0]
emit('update:modelValue', getPercent(touch.clientX))
}
}
const handleTouchEnd = () => {
isDragging.value = false
document.removeEventListener('touchmove', handleTouchMove)
document.removeEventListener('touchend', handleTouchEnd)
}
document.addEventListener('touchmove', handleTouchMove)
document.addEventListener('touchend', handleTouchEnd)
}
onMounted(() => {
if (progressRef.value) {
progressWidth.value = progressRef.value.offsetWidth
}
})
</script>
<style scoped>
.progress-bar {
position: relative;
padding: 8px 0;
cursor: pointer;
user-select: none;
}
.progress-track {
position: relative;
height: 4px;
background: #e0e0e0;
border-radius: 2px;
}
.progress-filled {
height: 100%;
background: #409eff;
border-radius: 2px;
transition: width 0.1s linear;
}
.progress-thumb {
position: absolute;
top: 50%;
width: 12px;
height: 12px;
background: #409eff;
border-radius: 50%;
transform: translate(-50%, -50%);
transition: transform 0.1s;
}
.progress-thumb:hover {
transform: translate(-50%, -50%) scale(1.3);
}
.progress-time {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #999;
margin-top: 4px;
}
</style>
vue
UnoCSS Variant Group Transformer
本节音频组件中大量使用了 UnoCSS 的变体组写法,需在 uno.config.ts 中配置:
// uno.config.ts
import { defineConfig, presetUno, transformerVariantGroup } from 'unocss'
export default defineConfig({
presets: [presetUno()],
transformers: [
transformerVariantGroup() // 启用变体组语法
]
})
typescript
变体组语法对比
<!-- 传统写法:每个变体前缀都要重复 -->
<div class="hover:bg-blue-500 hover:text-white hover:scale-110 lt-sm:text-sm lt-sm:p-2" />
<!-- 变体组写法:前缀只写一次 -->
<div class="hover:(bg-blue-500 text-white scale-110) lt-sm:(text-sm p-2)" />
html
| 语法 | 说明 |
|---|---|
hover:(...) | hover 状态下的样式组 |
lt-sm:(...) | 屏幕宽度小于 sm 断点时的样式 |
focus:(...) | focus 状态下的样式组 |
dark:(...) | 暗黑模式下的样式组 |
Bug 修复记录
Flex 布局溢出问题
问题:头部使用 el-row 组件时 flex-wrap 属性无法被 flex-nowrap 覆盖,导致移动端换行。
解决方案:将 el-row 替换为普通 div + flex 布局:
<!-- 修复前 -->
<el-row class="flex-nowrap">...</el-row>
<!-- 修复后 -->
<div class="flex flex-nowrap">...</div>
html
小结
- 进度条抽取为独立组件,通过
v-model实现双向数据绑定 - 同时支持鼠标拖拽和触摸滑动两种交互方式
- UnoCSS 变体组简化了响应式和状态样式的书写
el-row的内置flex-wrap可能与自定义样式冲突,必要时使用原生div替代
↑